Skip to content

feat(release): signed key list, yank list and key expiry (#226, part 1) - #235

Merged
Madraka merged 7 commits into
mainfrom
claude/release-trust-226
Nov 14, 2026
Merged

Madraka merged 7 commits into
mainfrom
claude/release-trust-226

Conversation

@Madraka

@Madraka Madraka commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Owner ceremony (in order; nothing below has been done, and no key exists yet)

The full commands are in docs/security/release-signing-keys.md › First-time setup.

  1. Generate the root, offline. Run node scripts/release-keys.ts root-init --key-id cloudhal-root-2026a --out <dir>. It asks for a passphrase twice and writes the private key only encrypted. Seal two copies of that file and its passphrase, in separate places. Then add the root's public key to the Root keys table and to PINNED_ROOTS (packages/release-verify/src/trust.ts) in one PR; a test keeps the two equal.
  2. Generate the yank key. Run node scripts/release-keys.ts yank-init --key-id cloudhal-yank-2026a --out <dir>. It writes cloudhal-yank-2026a.pem, mode 0600, and never prints it.
  3. Create the environment and the secret. In the repository settings, create the environment release-yank, with deployment branches main only and no required reviewer. Then run gh secret set RELEASE_YANK_KEY --env release-yank < cloudhal-yank-2026a.pem and move the .pem file offline.
  4. Sign the first lists.
  5. Publish. Commit security/keys.json, its .sig and the updated source in a reviewed PR, then deploy cloudhal.dev. The serving and the weekly workflow come in the second Release signing phase 2: signed keys.json and yanked.json, key expiry, KMS custody (RFC-0046 §9) #226 PR (see Not in this PR).

What

This is the first of two #226 PRs. It implements the decisions on #226, recorded in RFC-0046 §9.

  • RFC-0046 §9 amended with every decision:

    • where the two lists live, with the source of truth committed under security/;
    • the offline root: one holder and two sealed copies for now, a second holder with a second maintainer, signing only keys.json;
    • the yank key and its release-yank environment. It can only reduce trust, and a stolen yank key at worst denies service, with recovery through a root rotation;
    • the expiry rule;
    • freshness: keys.json lasts at most 13 months, and yanked.json exactly 30 days, re-signed weekly. A stale list blocks only deploy --release;
    • reduce-only merging;
    • --from requires both lists, unexpired;
    • KMS deferred to phase 3, with its triggers.
  • Contracts. Two new roots in core-contracts: release-keys (cloudhal.release-keys@1) and release-yank-list (cloudhal.release-yank-list@1). Each has valid and invalid fixtures for both languages. The TypeScript is generated and exported in the index; the Rust gets ReleaseKeys and ReleaseYankList with fixture round trips.

  • @cloudhal/release-verify:

    • verifyKeys: a pinned root (PINNED_ROOTS, empty until the ceremony), size limits, the schema, unique key ids, non-empty windows, issuedAt ≤ now (5 min skew) < expires ≤ issuedAt + 13 months.
    • verifyYanked: signed by an active yank-role key that the verified keys.json lists and that is valid now. expires must be exactly issuedAt + 30 days, and a stale list gives stale-yank-list.
    • mergeTrust (reduce-only): a listing with another public key or role revokes the pin; otherwise the stricter status wins and the window narrows to the intersection; listed is set only by a list verified now. revokedKeys revokes. Keys a list names but the consumer doesn't pin are never added.
    • verifyRelease refuses a statement whose createdAt is outside the key's window (key-expired), and a key past notAfter unless a fresh list marks it retiring (key-expired). It also refuses a yanked version (yanked).
    • New stable codes: key-expired, yanked, key-list-invalid, key-list-expired, yank-list-invalid, stale-yank-list. PINNED_KEYS now carries the window, and a test keeps it equal to the key doc's Since and Not after columns.
  • bundle-sign (release.yml):

    • it refuses when fewer than 90 days remain before the pinned key's notAfter (2027-11-12), dry runs included;
    • it refuses a statement whose createdAt is outside [PINNED_NOT_BEFORE, PINNED_NOT_AFTER].

    A test keeps both dates equal to the key doc.

  • scripts/release-keys.ts (Node built-ins only), with four commands:

    • root-init: the private key is written only passphrase-encrypted (PKCS#8 with AES-256), with mode 0600. It refuses to overwrite a key, and the public key goes to <id>.pub.json.
    • yank-init: the PEM is written with mode 0600 for the secret.
    • sign-keys: the root must open with the passphrase and match --root-pub; validity is 1 to 13 months (default 12); the output is self-verified.
    • sign-yanked: the key comes from RELEASE_YANK_KEY or --key-file, and the variable is deleted after it is read. The key must be keys.json's active yank key and inside its window. The list is append-only against the published one, which --published or --first-run must provide. expires is exactly 30 days after issuedAt.

    No command prints a private key, and a test checks that.

  • Sources under security/: keys.source.json (with cloudhal-release-2026a; the yank key is added at the ceremony), yank-list.json (empty, append-only) and a README.

  • Runbook (docs/security/release-signing-keys.md):

    • the statuses and the expiry rule, plus a root keys table (empty);
    • the yank key and the two lists, and the custody of the release key, with KMS deferred;
    • the owner's First-time setup;
    • yearly rotation (the successor listed by 2027-07-15), emergency revocation (yank key first, then root), and yanking a version.
  • Changeset for @cloudhal/release-verify and @cloudhal/core-contracts.

Update: serial against rollbacks (4ff6937)

  • yanked.json now carries a signed serial, one higher at every signing; sign-yanked numbers each list from the published one.
  • verifyYanked({ …, lastSerial }) refuses a lower serial with stale-yank-list. Such a lower serial is a rollback to an older, validly signed list.
  • Any consumer that can keep state uses this; for the CLI that comes with deploy --release.
  • The RFC and the runbook note that a stateless consumer (a Worker) can't detect a rollback inside the 30 days, and that the yank-list branch is append-only under a ruleset.
  • Tests: release-verify 78 (1 skipped), release-keys 9, core-contracts 614, Rust 23, drift clean.

Review round 1 (bb70e90)

  • Append-only on the consumer. YankMemory holds the yank key id, the highest serial, and every yanked version and revoked key id; rememberYanks produces it.
    • verifyYanked({ memory }) refuses a list from the same yank key that goes back in serial or drops a remembered entry (stale-yank-list).
    • A list from another, root-listed yank key starts a new memory. That is also how the list gets pruned, at about 200 KiB.
    • RFC §9, the runbook and the README say plainly that, for stateless consumers, a stolen yank key can undo yanks until the root rotates it. Emergency revocations are recorded in a root-signed keys.json within days. The "deny service only" claim is corrected.
  • Signers verify before writing. sign-keys and sign-yanked run the consumer's verifiers over the exact signed bytes before writing: the schema, unique ids, windows, timestamps, the 256 KiB limit, and the published list as memory. Nothing is written on failure.
    • sign-yanked verifies keys.json with the pinned root (--root-pub only before pinning).
    • A CI test validates security/keys.source.json and security/yank-list.json, and keeps their release keys equal to PINNED_KEYS and bundle-sign's window.
  • addMonths clamps to the last day of the target month (tested on 2027-01-31 + 13 months).
  • Rotation: the bundle-sign/doc test follows the key bundle-sign uses, so a successor row can be announced early.
  • RFC §11b: online consumers never fall back to a snapshot or cache; yanked.json is required too; --from takes both lists. §11c points 5 and 7 and the main list's question 2 are resolved.
  • Key doc: the by-hand verification uses @cloudhal/release-verify (window, expiry, yanks). The ceremony commits keys.json before the first yank list, and marks the steps that arrive with feat(release): publish the yank list weekly, serve both lists, attach them to releases (#226, part 2) #237.
  • publish-ci and the signed self-check use the PINNED_KEYS record (with its window) and accept a yank list.
  • Tests: release-verify 80 (1 skipped); cli release-keys 13, release-workflows 14, release-bundle-sign 5, release-bundle-verify 12, release-bundle-publish 9, release-bundle 18; biome and size clean.

Merge gate (6902752)

  • Replayed keys.json. YankMemory (now in yank-memory.ts) also keeps:

    • the newest keys.json issuedAt;
    • every yank key that a root list revoked, or that a newer key replaced.

    verifyKeys({ memory }) and verifyYanked({ memory }) refuse:

    • an older keys.json (key-list-invalid);
    • a revoked or replaced yank key, forever (yank-list-invalid);
    • another yank key, unless a newer keys.json lists it and no longer lists the old one as active (stale-yank-list).

    rememberYanks(yanked, keys, memory) carries the memory forward. Tests cover each case, including the attack: the previous keys.json plus an empty list from the old key.

  • One active yank key. verifyKeys, and so sign-keys, refuses two. Rotation replaces the key and never overlaps; the runbook and RFC §9 say so.

  • Tests: release-verify 84 passed, 1 skipped. cli: release-keys 13, workflows 14, sign 5, verify 12, publish 9. Biome and size are clean.

Not in this PR (the second #226 PR)

  • The weekly Yank list workflow (release-yank, actions pinned by SHA, least permissions), and serving /.well-known/cloudhal/ from cloudhal.dev.
  • Publishing, option A: the weekly workflow commits the signed yanked.json to an append-only yank-list branch, and the cloudhal.dev Worker serves that branch's raw file.

KMS

KMS custody is deferred to phase 3. Follow-up: #234. Signing stays in the release-signing environment with a required reviewer. #226's done-when doesn't need KMS: revocation and expiry, the real gaps, are closed here.

Tests (run locally, one at a time)

  • packages/release-verify: 77 passed, 1 skipped (the opt-in real tarball). That includes the new trust.test.ts (14), which covers:
    • roots, signatures, tampering, freshness and the 13-month cap;
    • the yank signer's role, status and window, the exact 30-day expires, and staleness;
    • reduce-only merging (conflicts, narrowing, never adding, never re-activating);
    • the real v0.9.0 statement against its window, pinned-only expiry, the retiring-and-listed exception, and statements created outside a window;
    • yanked versions.
  • packages/cli:
    • release-keys.test.ts: 8 passed (file modes, encrypted root, no secret printed, refusals, append-only, the CLI path through RELEASE_YANK_KEY, and outputs checked by the consumer's verifier);
    • release-bundle-sign: 5 passed (including the new createdAt window);
    • release-workflows: 14 passed (both pinned dates equal to the doc).
  • packages/core-contracts: 612 passed, with the new fixtures.
  • Rust: cargo test -p cloudhal-schema passes 23 fixture tests (2 of them new), and cargo clippy -p cloudhal-schema --all-targets -D warnings and cargo fmt --check are clean.
  • Checks: pnpm gen is clean, and typechecks, biome and size:check are ok. pnpm drift is clean after the commit.

Refs #226 (the second PR closes it)

🤖 Generated with Claude Code

Madraka and others added 3 commits November 14, 2026 17:23
…ate (#226)

RFC-0046 §9: bundle-sign refuses when the active key's notAfter is under
90 days away, so the next key is announced and pinned in time.

- release.yml bundle-sign: PINNED_NOT_AFTER next to the pinned key id
  and public key; the check runs before the key is read, in dry runs too.
- docs/security/release-signing-keys.md: a "Not after" column
  (cloudhal-release-2026a: 2027-11-12, pending the owner's decision),
  what the date means for consumers, and rotation starting 120 days ahead.
- Tests: the inline script refuses at 89 and -1 days and signs at 91; the
  doc's not-after date equals the workflow's pin.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
The #226 decisions, recorded in RFC-0046 §9 and built here:

- Contracts: release-keys (keys.json, root-signed, expires at most 13
  months after issuedAt) and release-yank-list (yanked.json, signed by a
  yank-role key, expires exactly 30 days after issuedAt), with fixtures
  for TS and Rust.
- @cloudhal/release-verify:
  - verifyKeys and verifyYanked check signatures and freshness, and a
    stale yank list is refused with stale-yank-list;
  - mergeTrust only reduces trust in the pinned keys (revoke, retiring,
    a narrower window) and never adds a key;
  - verifyRelease enforces each key's window (key-expired; after
    notAfter a key verifies only while a fresh keys.json lists it as
    retiring) and refuses yanked versions (yanked);
  - PINNED_KEYS carries each key's window, and PINNED_ROOTS is empty
    until the owner's root ceremony.
- bundle-sign refuses when fewer than 90 days remain before notAfter,
  and refuses a statement created outside the key's window.
- scripts/release-keys.ts (built-ins only): root-init (passphrase-
  encrypted root), yank-init, sign-keys and sign-yanked (append-only
  against the published list). No command prints a private key.
- security/: keys.source.json, yank-list.json (append-only) and a README.
- The runbook in docs/security/release-signing-keys.md: first-time
  setup, rotation, emergency revocation and yanking. KMS is deferred
  to phase 3.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
A yank list now carries a signed `serial` that increases by one at every
signing (sign-yanked numbers it one above the published list's).
verifyYanked takes `lastSerial`: a consumer that keeps state (the CLI)
refuses a lower serial, a rollback to an older, validly signed list,
with stale-yank-list. RFC-0046 §9 and the runbook note that a stateless
consumer cannot detect a rollback inside the 30 days, and that the
yank-list branch is append-only under a ruleset.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
- Append-only on the consumer: YankMemory (yank key id, highest serial,
  every yanked version and revoked key id) plus rememberYanks.
  verifyYanked({ memory }) refuses a list from the same yank key that
  drops a remembered entry or goes back in serial (stale-yank-list). A
  list from another root-listed yank key starts a new memory (rotation
  and pruning). RFC §9, the runbook and the README now say what a stolen
  yank key can do for stateless consumers (undo yanks until the root
  rotates it), and emergency revocations go into a root-signed keys.json
  within days. The "deny service only" claim is corrected everywhere.
- sign-keys and sign-yanked run the consumer's verifiers over the exact
  signed bytes before writing (schema, unique ids, windows, timestamps,
  the 256 KiB limit, the published list as memory), and nothing is
  written on failure. sign-yanked verifies keys.json with the pinned
  root (--root-pub before pinning). A CI test validates
  security/keys.source.json and security/yank-list.json and keeps the
  release keys equal to PINNED_KEYS and bundle-sign's window.
- addMonths clamps to the last day of the target month (tested on
  01-31 + 13 months).
- The bundle-sign/doc test follows the key bundle-sign uses, so a
  successor can be announced first.
- RFC §11b: online, never fall back to a snapshot; deploy --release also
  requires yanked.json; --from takes both lists. Open questions §11c 5
  and 7 and the main list's 2 are marked resolved.
- The key doc's by-hand verification now uses @cloudhal/release-verify
  (window, expiry, yanks), and the ceremony order commits keys.json
  before the first yank list, with the workflow steps marked as
  arriving with the second PR.
- publish-ci verifyAsConsumer and the signed self-check use the
  PINNED_KEYS record (with its window), and accept a yank list.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
Madraka added a commit that referenced this pull request Nov 14, 2026
- yank-list.yml installs exactly @cloudhal/release-verify's pinned
  dependencies, with --ignore-scripts and before the key reaches any
  step, so sign-yanked verifies the exact bytes it signs (keys.json
  against the pinned root, the published list as memory). The test
  allows only that install.
- trustAssets returns the verified yank list too. publish-ci now runs
  the consumer check of the bundle after it, with that list, so a yanked
  version or a key the list revokes is refused before anything is
  uploaded.
- The trust-asset tests use the async signers.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
… key (#226)

- YankMemory also keeps the newest keys.json issuedAt and every yank key a
  root list revoked or that a newer key replaced (new module yank-memory.ts).
  verifyKeys and verifyYanked refuse an older keys.json, a retired yank key,
  and another yank key unless a newer keys.json replaces the remembered one.
- verifyKeys (and so sign-keys) refuses a keys.json with two active yank keys.
- RFC-0046 §9, the runbook, the README and the changeset say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
Madraka added a commit that referenced this pull request Nov 14, 2026
- yank-list.yml installs exactly @cloudhal/release-verify's pinned
  dependencies, with --ignore-scripts and before the key reaches any
  step, so sign-yanked verifies the exact bytes it signs (keys.json
  against the pinned root, the published list as memory). The test
  allows only that install.
- trustAssets returns the verified yank list too. publish-ci now runs
  the consumer check of the bundle after it, with that list, so a yanked
  version or a key the list revokes is refused before anything is
  uploaded.
- The trust-asset tests use the async signers.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
sign-yanked's self-check gave the published list's memory the current
keys.json issuedAt, so a list from the new yank key under that same
keys.json was refused as stale. When the published list has another signer,
the memory names no keys.json (the epoch), as for a consumer that kept the
earlier one; every published entry is still carried (checkAppendOnly) and
the serial continues. An end-to-end rotation test covers publish under
2026a, a root list revoking it, signing under 2026b, a CLI memory moving
from 2026a/T1 to 2026b/T2, and the old list refused afterwards.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka
Madraka force-pushed the claude/release-trust-226 branch from a24a277 to 9e6b5d1 Compare November 14, 2026 15:29
Madraka added a commit that referenced this pull request Nov 14, 2026
- yank-list.yml installs exactly @cloudhal/release-verify's pinned
  dependencies, with --ignore-scripts and before the key reaches any
  step, so sign-yanked verifies the exact bytes it signs (keys.json
  against the pinned root, the published list as memory). The test
  allows only that install.
- trustAssets returns the verified yank list too. publish-ci now runs
  the consumer check of the bundle after it, with that list, so a yanked
  version or a key the list revokes is refused before anything is
  uploaded.
- The trust-asset tests use the async signers.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
…ify (#226)

The key doc's by-hand script now uses @cloudhal/release-verify (#235 round 1),
so the core-contracts doc tests assert what it does: the request binds the
version and the channel (now passed explicitly), both signed lists are
verified and applied (mergeTrust, yanked), no fallback to an old copy, every
tar entry is read, and the two GitHub checks remain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
Madraka added a commit that referenced this pull request Nov 14, 2026
- yank-list.yml installs exactly @cloudhal/release-verify's pinned
  dependencies, with --ignore-scripts and before the key reaches any
  step, so sign-yanked verifies the exact bytes it signs (keys.json
  against the pinned root, the published list as memory). The test
  allows only that install.
- trustAssets returns the verified yank list too. publish-ci now runs
  the consumer check of the bundle after it, with that list, so a yanked
  version or a key the list revokes is refused before anything is
  uploaded.
- The trust-asset tests use the async signers.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka
Madraka merged commit 1db5edc into main Nov 14, 2026
9 checks passed
@Madraka
Madraka deleted the claude/release-trust-226 branch November 14, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant